🕸️ Ada Research Browser

bt-03-audit-logger-php.md
← Back

BT-03: AuditLogger PHP Class

Goal: Implement a thin audit logging middleware class that captures all security-relevant events to the audit_events PostgreSQL table.

Files: - Create: /var/www/html/eqmon/lib/AuditLogger.php

Depends on: BT-02


Step 1: Implement AuditLogger.php

<?php
/**
 * AuditLogger - Centralized audit event logging for NIST 800-171 compliance
 *
 * NIST Controls: 3.3.1 (create audit logs), 3.3.2 (user traceability),
 *                3.3.7 (UTC timestamps), 3.3.8 (protected logging)
 *
 * @package EQMON
 */

class AuditLogger
{
    // Event categories matching NIST control families
    const CAT_AUTH   = 'auth';    // Authentication events (3.5.x)
    const CAT_ACCESS = 'access';  // Resource access events (3.1.x)
    const CAT_ADMIN  = 'admin';   // Administrative actions (3.1.7)
    const CAT_DATA   = 'data';    // CUI data operations (3.1.3)
    const CAT_AI     = 'ai';     // AI chat events
    const CAT_SYSTEM = 'system';  // System events (3.14.x)

    // Result values
    const RESULT_SUCCESS = 'success';
    const RESULT_FAILURE = 'failure';
    const RESULT_DENIED  = 'denied';

    private static ?PDO $db = null;
    private static bool $failed = false;

    /**
     * Log an audit event.
     *
     * @param string      $category     Event category (CAT_* constant)
     * @param string      $action       Specific action (e.g., 'login', 'view_bearing')
     * @param string      $result       Outcome (RESULT_* constant)
     * @param string|null $userId       User performing the action
     * @param array       $metadata     Additional context (JSONB)
     * @param bool        $cuiAccessed  Whether CUI was involved
     * @param string|null $resourceType API endpoint or page type
     * @param string|null $resourceId   Specific resource identifier
     * @param string|null $instanceId   Tenant context
     */
    public static function log(
        string  $category,
        string  $action,
        string  $result = self::RESULT_SUCCESS,
        ?string $userId = null,
        array   $metadata = [],
        bool    $cuiAccessed = false,
        ?string $resourceType = null,
        ?string $resourceId = null,
        ?string $instanceId = null
    ): void {
        // Don't retry if DB connection already failed this request
        if (self::$failed) return;

        try {
            $db = self::getDb();
            if (!$db) return;

            $stmt = $db->prepare("
                INSERT INTO audit_events
                    (category, action, result, user_id, session_id,
                     ip_address, user_agent, resource_type, resource_id,
                     instance_id, cui_accessed, metadata)
                VALUES
                    (:category, :action, :result, :user_id, :session_id,
                     :ip_address, :user_agent, :resource_type, :resource_id,
                     :instance_id, :cui_accessed, :metadata)
            ");

            $stmt->execute([
                ':category'      => $category,
                ':action'        => $action,
                ':result'        => $result,
                ':user_id'       => $userId,
                ':session_id'    => self::getSessionId(),
                ':ip_address'    => self::getClientIp(),
                ':user_agent'    => self::getUserAgent(),
                ':resource_type' => $resourceType ?? self::getResourceType(),
                ':resource_id'   => $resourceId,
                ':instance_id'   => $instanceId,
                ':cui_accessed'  => $cuiAccessed ? 'true' : 'false',
                ':metadata'      => json_encode($metadata),
            ]);
        } catch (Throwable $e) {
            self::$failed = true;
            // Log failure to syslog (3.3.4 — audit failure alerting)
            openlog('eqmon-audit', LOG_PID, LOG_AUTH);
            syslog(LOG_CRIT, "AUDIT_FAILURE: " . $e->getMessage() .
                " category={$category} action={$action}");
            closelog();
            // Also to PHP error log as backup
            error_log("AuditLogger FAILURE: " . $e->getMessage());
        }
    }

    /**
     * Log an API access event from middleware context.
     * Convenience method for requireApiAuth() integration.
     */
    public static function logApiAccess(array $session, string $result = self::RESULT_SUCCESS): void
    {
        $cuiAccessed = self::isCuiEndpoint();
        self::log(
            self::CAT_ACCESS,
            'api_request',
            $result,
            $session['user_id'] ?? null,
            [
                'method'  => $_SERVER['REQUEST_METHOD'] ?? 'unknown',
                'role'    => $session['role'] ?? 'unknown',
            ],
            $cuiAccessed,
            null, null,
            $session['instance_id'] ?? null
        );
    }

    /**
     * Log an authentication event.
     */
    public static function logAuth(
        string  $action,
        string  $result,
        ?string $userId = null,
        ?string $email = null,
        ?string $authSource = null,
        ?string $failureReason = null
    ): void {
        self::log(
            self::CAT_AUTH,
            $action,
            $result,
            $userId,
            array_filter([
                'email'          => $email,
                'auth_source'    => $authSource,
                'failure_reason' => $failureReason,
            ])
        );
    }

    /**
     * Log an admin action.
     */
    public static function logAdmin(
        string  $action,
        string  $result,
        ?string $userId,
        ?string $targetUserId = null,
        array   $details = []
    ): void {
        if ($targetUserId) {
            $details['target_user_id'] = $targetUserId;
        }
        self::log(self::CAT_ADMIN, $action, $result, $userId, $details);
    }

    /**
     * Log an AI chat event.
     */
    public static function logAI(
        string  $action,
        string  $result,
        ?string $userId,
        array   $details = [],
        bool    $cuiAccessed = true
    ): void {
        self::log(self::CAT_AI, $action, $result, $userId, $details, $cuiAccessed);
    }

    /**
     * Check if the current endpoint involves CUI.
     */
    private static function isCuiEndpoint(): bool
    {
        $uri = $_SERVER['REQUEST_URI'] ?? '';
        $cuiPaths = [
            '/api/ai_chat.php',
            '/api/stream.php',
            '/api/bearings',
            '/api/devices',
            '/api/measurements',
            '/api/alerts',
            '/api/reports',
            '/api/export',
        ];
        foreach ($cuiPaths as $path) {
            if (strpos($uri, $path) !== false) return true;
        }
        return false;
    }

    private static function getDb(): ?PDO
    {
        if (self::$db !== null) return self::$db;

        try {
            self::$db = new PDO(
                'pgsql:host=' . ($_ENV['DB_HOST'] ?? 'localhost') .
                ';dbname=' . ($_ENV['DB_NAME'] ?? 'eqmon'),
                $_ENV['DB_USER'] ?? 'eqmon',
                $_ENV['EQMON_AUTH_DB_PASS'] ?? $_ENV['DB_PASS'] ?? '',
                [
                    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                    PDO::ATTR_EMULATE_PREPARES => false,
                ]
            );
            return self::$db;
        } catch (PDOException $e) {
            self::$failed = true;
            openlog('eqmon-audit', LOG_PID, LOG_AUTH);
            syslog(LOG_CRIT, "AUDIT_DB_FAILURE: Cannot connect - " . $e->getMessage());
            closelog();
            return null;
        }
    }

    private static function getClientIp(): ?string
    {
        $ip = $_SERVER['HTTP_X_FORWARDED_FOR']
            ?? $_SERVER['HTTP_X_REAL_IP']
            ?? $_SERVER['REMOTE_ADDR']
            ?? null;
        if ($ip) {
            $ip = explode(',', $ip)[0];
            $ip = trim($ip);
        }
        return $ip;
    }

    private static function getUserAgent(): ?string
    {
        $ua = $_SERVER['HTTP_USER_AGENT'] ?? null;
        return $ua ? substr($ua, 0, 500) : null;
    }

    private static function getSessionId(): ?string
    {
        $cookie = $_COOKIE['eqmon_session'] ?? null;
        if (!$cookie) return null;
        // Return first 16 chars of JWT as session identifier (don't log full JWT)
        return substr($cookie, 0, 16) . '...';
    }

    private static function getResourceType(): ?string
    {
        $uri = $_SERVER['REQUEST_URI'] ?? null;
        if (!$uri) return null;
        // Strip query string and return path
        return strtok($uri, '?');
    }
}

Step 2: Verify the class loads without errors

php -r "require '/var/www/html/eqmon/lib/AuditLogger.php'; echo 'OK';"
# Expected: OK

Step 3: Write a quick integration test

php -r "
require '/var/www/html/eqmon/lib/AuditLogger.php';
\$_ENV['DB_HOST'] = 'localhost';
\$_ENV['DB_NAME'] = 'eqmon';
\$_ENV['DB_USER'] = 'eqmon';
\$_ENV['DB_PASS'] = '';
AuditLogger::log('system', 'test_event', 'success', null, ['test' => true]);
echo 'Logged OK\n';
"

Verify event exists:

sudo -u postgres psql -d eqmon -c "SELECT event_id, category, action, result FROM audit_events ORDER BY timestamp DESC LIMIT 1;"

Step 4: Commit

cd /var/www/html/eqmon
git add lib/AuditLogger.php
git commit -m "feat: add AuditLogger for centralized audit logging (NIST 3.3.1, 3.3.2)"